在 API 開發中,我們經常會遇到關聯模型之間的資料需要同時返回的情況。
特別是在處理「一對一」或「一對多」關聯時,多層結構往往是常態。
我們希望以巢狀結構(Nested Objects)的方式返回資料,這樣可以讓 API 的使用者一次取得必要資訊,而不需要進行多次請求。
本文將繼續使用並擴充「單一文章資訊」API 這個範例,講述如何在 Django Ninja 中實現巢狀結構回應,讓我們的 API 回應更加豐富、有體系。
本文所有的程式碼變動,可參考這個 PR。
在之前的 API 設計中,「取得單一文章資訊」的回應包括了文章資訊及作者的 id:
class PostResponse(Schema):
id: int
title: str
content: str
author_id: int
created_at: datetime
updated_at: datetime
有經驗的開發者都知道,無論是id
還是author_id
,通常不是給服務的使用者看的——而是給前端人員靈活運用的。
比如在系統的畫面中,文章可能包括作者的個人資訊連結,點進去可以看到作者資訊。此時前端必須透過 id,再呼叫另一支 API「取得用戶資訊」來獲得額外的內容。
如果額外資訊很多,這樣的「解耦」設計是非常合理的。但如果我們希望一併呈現作者的「必要資訊」,那分次呼叫的設計就略嫌拖沓。
所以我們需要巢狀結構!
API 可以直接在回應中,嵌入作者的「必要資訊」,這樣用戶就不必再進行多次請求。這裡我們以一併顯示作者的「名字」和「email」為例。
只需要做一件事,就可以讓回應的內容、結構有所不同——重新定義PostResponse
:
from ninja import Schema
from datetime import datetime
class _AuthorInfo(Schema):
id: int
username: str
email: str
class PostResponse(Schema):
id: int
title: str
content: str
author: _AuthorInfo # 巢狀結構,包含作者資訊
created_at: datetime
updated_at: datetime
_AuthorInfo
包含了作者的id
、name
和email
,並將這個結構嵌入PostResponse
中的author
欄位(從author_id
易名而來,因為資訊內涵已有所不同)。
如此一來,我們便可以同時獲得文章和作者的必要資訊。
你可能留意到我在_AuthorInfo
使用了「底線開頭」這個命名原則。在 Python 中,這是一種慣例,用來表示這個屬性、函式、類別主要是作為內部使用。
所謂的「內部」可以有很多種解讀,而這裡我的用意是:它只是某個或多個 Schema 的一部分,不直接供 view 函式調用。
別小看這個命名細節。隨著你的 Schema 數量增加,在開發新 API 時,你總是需要先瀏覽現有的 Schema,以決定是重新定義還是延用既有的。
此時有這樣的命名區別就顯得很「貼心」了——你不必在大大小小的 Schema 中翻來覆去,看得眼睛要脫窗。
撰寫巢狀 Schema 的機會不少,所以我認為養成這樣的好習慣是值得的。
我們來看 API 的回應:
// http://127.0.0.1:8000/posts/2/
{
"id": 2,
"title": "Alice's Django Ninja Post 1",
"content": "Alice's Django Ninja Post 1 content",
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},
"created_at": "2024-09-12T02:28:16.801Z",
"updated_at": "2024-09-12T02:28:16.801Z"
}
看看新的author
欄位內容,巢狀結構,非常完美!
用戶可以直接到看文章作者的名字與 email,如果想看更多作者資訊,依舊能透過id
欄位,再讓前端呼叫另一支 API。
這是一個理想的折衷方案。
前面的「折衷方案」確實挺理想。不過,有時我們的需求更簡單。
比如在「取得文章列表」API 中,我們可能也需要顯示作者的資訊——但此時只要名字就足夠了。
不需要作者 id,更不用 email,只要名字即可。
那麼,為何稱之為「攤平巢狀資訊」呢?因為作者的名字並非Post
模型的直接屬性,它實際上來自於關聯模型——User
。
我們必須要把有關作者的巢狀資訊進行化簡。
本來是這樣:
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},
現在變成這樣:
"author_name": "Alice",
從兩層變回一層(但不是作者 id 而是名字了),所以稱為「攤平」(flatten)。
還記得「取得文章列表」API 的回應格式,其實是和「取得單一文章資訊」共用的:
@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
"""
取得文章列表
"""
...
兩者都使用了PostResponse
。
本文上半部對「取得單一文章資訊」回應的修改,也會影響到「取得文章列表」——這通常不是我們想要的結果。
所以,我們要為「取得文章列表」API 建立一個屬於自己的回應 Schema,並依照前面提到的需求,簡化資訊!
我打算:
content
)還有更新時間(updated_at
)這兩個欄位,因為在列表中並不需要。@property
我們先看看新 Schema 如何定義:
class PostListResponse(Schema):
id: int
title: str
created_at: datetime
author_name: str
你可能覺得奇怪,哪來的author_name
屬性?Post
模型並沒有啊?
沒錯!因為那是我們自己定義的——使用@property
:
# post/models.py
class Post(models.Model):
...
@property
def author_name(self) -> str:
return self.author.username
如此一來,你的 Post 模型物件,就會有author_name
這個屬性了。
但要注意,呼叫這個屬性通常意味著觸發第二次查詢(因為它是關聯模型上的屬性),所以 view 函式中要搭配 Django QuerySet 方法select_related
:
posts.filter(title__icontains=title).select_related('author')
這是 Django ORM 中常見的「N+1」議題,在此先不展開。
你可能覺得這個方式好像不怎麼優雅(至少我第一次看到時就是這麼想!)——尤其是和 Django REST framework 的做法相比。
Django REST framework 會在序列化器中這樣寫:
author_name = serializers.CharField(source="author.name")
是不是簡潔很多?
但這確實是 Django Ninja 作者早期推薦的方式。
別擔心,第 16 篇我們會介紹更好、更現代化的做法。不過@property
在某些情況下,還是很有用的。
最後,我們來看看「取得文章列表」API 的新回應:(假設只有一篇文章)
// http://127.0.0.1:8000/posts/
[
{
"id": 1,
"title": "Alice's Django Ninja Post 1",
"created_at": "2024-09-12T02:28:16.801Z",
"author_name": "Alice" // 攤平後的作者名字
}
]
漂亮!
這篇文章中,我們展示了如何在 Django Ninja 中使用 Schema 實現巢狀結構回應。
接著介紹如何「攤平」這個巢狀結構,把原來的作者 id 替換成名字欄位。
這些方法大大增加了 API 回應的靈活性。
下一篇文章,我們將討論 Django Ninja 和 Django REST Framework 在序列化與回應結構處理上的不同設計理念,並比較兩者的優劣。
本文同步發表於我的部落格——Code and Me
我第一時間看也覺得用@property好麻煩XDD 先盲猜一下可以透過 pydantic 的 Field(exclude=False) 來讓特定欄位不被序列會輸出,但內部依然可以使用。
另外同意幫屬性加底線命名很有幫助!
因為有時候一些 func 可能要執行一些操作,但這個 func 操作可能只有某個特定的另一個函式會呼叫,而且通常可以根據名字就知道他做了什,這時候我會將此 func 命名以底線開頭,通常代表這是私有的 func,只有特定的函式會呼叫,正常 call 服務的人應該不會 call 這種以底線開頭的 func。
當然 python 的自由大家都知道,即使命名為底線開頭依然可以 call 這個 func,只要你明確知道他在做什就可以。
當然要養成這種習慣也不容易,我後來是使用 ruff 的 D 系列規則來幫助自己養成這個習慣和順便寫 docstring。
程序員多數都是懶惰的 (至少我就是!) ,要我寫 docstring 我可能也不是很願意,表示直接看 code 不就懂了?
而上面的 D 系列規則允許私有方法可以不用寫 docstring,但 public 的則要寫(非底限開頭的),所以這時候我為了讓自己少寫 docstring,我就會盡量讓一些內部使用的 func 都加上底線,但最外層要給人家 call 的 public func 當然是不能加,所以就乖乖寫這幾個就好,在docstring 寫清楚這個 func 是做什的,輸入輸出是什。
當你認真將處理這些 private func 後,你可能會發現你大約有7成的 func 都會變私有的,很多時候這些 code 給別人看時候他也許只要看看名字就好,他應該更把注意力專住在那些 public func 上
哈哈哈,你完全活用了 Ruff,我還停留在 linter、formatter
我的 Ruff 系列目前是兩篇,預計還有第三篇,得先好好研究一下包括你提到的 D 系列等各種規則,挑選一些實用的來介紹——顯然你說的這個和 docstring 有關的,就很有必要!
Ruff 提供了遠超 PEP 8 範圍的各種神奇檢查,沒用到真的有點可惜
單底線開頭的私有命名慣例,真的是非常值得重視,事實上用的人多不多,就不得而知,但只要有人留意到這點,我肯定是另眼相看。這對自己或協作者都是非常貼心的,程式碼也更好維護
零件型的函式、類別,不用底線開頭真的會讓人「發芬」!
Ruff 太強了 而且他還沒有使出全力的樣子
我真的是直接規則 ALL 開下去就對了,不合理/不適合自己團隊的在一一排除掉就好,很多時候這些程式都比人還聰明XDD
D系列規則有些依然還是很煩人,我有幾個還是關掉了
例如 __init__ 檔案的 docstring
class 的 docstring (一般 class 還好 像BaseModel要一直寫就有點煩XD)
主要還是針對一般 func 的 docstring 很有幫助! 讓自己可以把該私有的 func 私有化,最終可能十個 func 只留一兩個公有 func 給外部 call
確實,Model 類的 class,我還真的沒寫過 docstring,大概只有擁有「D 之意志」的人,才不得不認真思考這個問題
擋下我 commit 的不是 trailing-whitespace 或 end-of-file-fixer,而是因為沒寫 docstr,真是太好了